-
Notifications
You must be signed in to change notification settings - Fork 306
profile: Implement profile screen for users #287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi! This will be great to have.
There's a lot happening in this PR, and correspondingly a lot of comments below. I haven't fully reviewed the widgets changes or the corresponding tests, figuring that this is plenty to discuss for one round.
One thing that will definitely help with both reviewing and merging is to split this into a number of smaller commits on logical boundaries. Some specific suggestions in the comments below, but broadly I think the sequence would look like:
- API pure refactors, like moving code around.
- API changes, along with corresponding changes in model code. These would be several commits: one for the UserRole enum, one for realmDefaultExternalAccounts, one for CustomProfileFieldsEvent, maybe one I'm forgetting.
- Add the timezone dependency. The line in
main.dart
to set it up probably goes in this commit too, on the theory that the dependency is in an unusable state before that's added. - Add the new screen, and the ways to navigate to it.
pubspec.yaml
Outdated
@@ -57,6 +57,7 @@ dependencies: | |||
package_info_plus: ^4.0.1 | |||
collection: ^1.17.2 | |||
url_launcher: ^6.1.11 | |||
timezone: ^0.9.2 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add the dependency as a separate small prep commit. See db44a9b for a recent example.
lib/api/model/events.dart
Outdated
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) => | ||
_$CustomProfileFieldsEventFromJson(json); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) => | |
_$CustomProfileFieldsEventFromJson(json); | |
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) => | |
_$CustomProfileFieldsEventFromJson(json); |
/// For docs, search for "custom_profile_fields:" | ||
/// in <https://zulip.com/api/register-queue>. | ||
@JsonSerializable(fieldRename: FieldRename.snake) | ||
class CustomProfileField { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the reason for moving this out of model.dart
and into initial_snapshot.dart
?
My inclination would be to keep it in model.dart
. As this PR shows, this type appears in events as well as in the initial snapshot. I think of this api/model/model.dart
as the place for types that appear across multiple areas of the API, whereas those other two api/model/
files events.dart
and initial_snapshot.dart
are for types that appear specifically in events and in the initial snapshot respectively. (And types that are specific to other particular endpoints generally go in the respective api/route/*.dart
files.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't realize there were other structures in model.dart
that lived in two places (although that is clear now). I only moved it thinking that it was initially constructed in initial_snapshot.dart
. Moving it back here.
@@ -120,6 +78,41 @@ class User { | |||
Map<String, dynamic> toJson() => _$UserToJson(this); | |||
} | |||
|
|||
/// As in [User.profileData]. | |||
@JsonSerializable(fieldRename: FieldRename.snake) | |||
class ProfileFieldUserData { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, moving this to come after User
is a good idea — the rest of this file is organized in general-to-specific order, so good to match that here.
Let's do that in its own commit, though, or I guess combined with adding that handy line of dartdoc, and perhaps combined with any other code moves of a similar nature. That commit can then also be labeled as NFC.
@JsonKey(unknownEnumValue: UserRole.unknown) | ||
UserRole role; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's separate out adding this enum as its own commit.
(I.e. adding the class, changing this field to use it, and other closely-connected changes like in RealmUserUpdateEvent, as one commit; but separate from adding the CustomProfileFieldsEvent type, moving the definition of ProfileFieldUserData, adding the new UI, and so on.)
lib/widgets/profile.dart
Outdated
style: const TextStyle(fontWeight: FontWeight.bold)), | ||
(user.isBot ? Text(user.email) : const SizedBox.shrink()), | ||
Text(user.role.label), | ||
// Text("Active about XXX"), // TODO render active status |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This TODO comment can point to the relevant issue:
// Text("Active about XXX"), // TODO render active status | |
// Text("Active about XXX"), // TODO(#196) render active status |
lib/widgets/profile.dart
Outdated
final Widget profileDataTable; | ||
if (user.profileData != null) { | ||
profileDataTable = ProfileDataTable(user:user); | ||
} else { | ||
profileDataTable = const SizedBox.shrink(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this condition would be appropriate to push down inside ProfileDataTable.build
, simplifying this code in the parent.
lib/widgets/profile.dart
Outdated
final userLocalNow = getNowInTimezone(user.timezone); | ||
if (userLocalNow != null) { | ||
localTimeWidget = Text("${DateFormat.jm().format(userLocalNow)} Local time", | ||
style: const TextStyle(fontWeight: FontWeight.normal)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and the rest of the localTimeWidget
logic can probably best be moved into a little widget of their own, as the work they're doing is pretty independent of the rest of this method.
As a bonus, that would set us up to make an enhancement where that widget would rebuild every time the answer changes (so at the start of each minute), instead of getting out of date if the screen stays open for a while and then you come back to it. That'd be neat. (Wouldn't be worth tons of effort; but my guess is that it's pretty straightforward once one lays hands on the right API.)
test/api/model/model_test.dart
Outdated
test('realm_default_external_account', () { | ||
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({}); | ||
check(realmDefaultExternalAccount) | ||
..name.equals('site') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this is basically testing the fromJson
method, and so checking that e.g. the name
field it gets equals the value of the name
property in the JSON.
That means that when it says equals('site')
, the fact that 'site'
is the same value as appears in the input JSON is an essential part of the test. Because it's essential to the test, it's best if that fact can be found within the test case's own source code. For example like this:
test('realm_default_external_account', () { | |
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({}); | |
check(realmDefaultExternalAccount) | |
..name.equals('site') | |
test('realm_default_external_account', () { | |
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({ | |
'name': 'site', | |
'hint': 'hint for site', | |
'text': 'description for site', | |
'url_pattern': 'http://example/%{username}', | |
}); | |
check(realmDefaultExternalAccount) | |
..name.equals('site') |
Where the baseJson
pattern comes in handy, on the other hand (as well as other ways of expressing default/baseline test data outside an individual test), is for all the data whose specifics are irrelevant to a given test, where the test just needs something there that makes the data well-formed.
So for example up at the top of this file:
test('delivery_email', () {
check(mkUser({'delivery_email': 'name@email.com'}).deliveryEmailStaleDoNotUse)
.equals('name@email.com');
that test doesn't care about any of the other properties in the input JSON, except that they have to be there and have appropriate types so that fromJson
doesn't throw. So the baseJson
encoded in mkUser
serves it very well for those. It only cares about delivery_email
— and so it specifies that one property explicitly.
We could have written that test like this:
test('delivery_email', () {
check(mkUser({}).deliveryEmailStaleDoNotUse)
.equals('name@example.com');
relying on the delivery_email
that's supplied in baseJson
. But then the test wouldn't be self-contained — for the reader to understand what the test is trying to say, they'd need to go consult what's in baseJson
. So instead we make the intended delivery_email
value explicit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this particular test, it's really just testing the auto-generated code for fromJson
, because we happily have no interesting customizations on this type. So what I'd therefore actually prefer to do is to leave the test out.
Where we do have customizations of our own, especially any nontrivial ones, it's good to test those specifically. That's what you see happening earlier in this file on e.g. User
— we test the handling of profileData
and isSystemBot
, which involve code of our own. Also of deliveryEmailStaleDoNotUse
, which is a bit more marginal but does have our own customization in the form of that JsonKey
annotation.
519b570
to
f4648d4
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi for the revision! Comments below.
I haven't yet read the new tests in detail, but I figured this was a good point at which there's plenty of comments for one round of review.
lib/widgets/profile.dart
Outdated
final allRecipientIds = [store.account.userId, userId]; | ||
allRecipientIds.sort(); | ||
final narrow = DmNarrow( | ||
selfUserId: store.account.userId, | ||
allRecipientIds: allRecipientIds); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will throw when the other user is the self-user.
Probably a good way to handle this is to add another constructor on DmNarrow
, something like
factory DmNarrow.withUser(int userId, {required int selfUserId}) {
// …
(or maybe make userId
a named parameter too).
Then that can encapsulate this logic, plus the case where the user is self.
lib/widgets/profile.dart
Outdated
if (user.deliveryEmailStaleDoNotUse == null) { | ||
return const SizedBox.shrink(); | ||
} | ||
return Text(user.deliveryEmailStaleDoNotUse!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(You asked about this in our video call, and our plan is to file a separate issue for it and leave it out of the initial version of this profile screen. Previous related discussion at #287 (comment) .)
lib/widgets/profile.dart
Outdated
Widget build(BuildContext context) { | ||
final roleLabel = switch (role) { | ||
UserRole.owner => "Owner", | ||
UserRole.administrator => "Administrator", | ||
UserRole.moderator => "Moderator", | ||
UserRole.member => "Member", | ||
UserRole.guest => "Guest", | ||
UserRole.unknown => "Unknown", | ||
}; | ||
return Text(roleLabel); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is fine, but to be clear (and in case it feels lighter-weight to you), what I had in mind at #287 (comment) was more like
- have a function that's just this
switch
statement, returning a String; - then ProfilePage.build can have a
Text
widget directly in the widget tree that it returns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was in the mode of making everything consistently a widget, but it doesn't feel like a necessity here. I do prefer how lighter weight making this a function becomes.
lib/main.dart
Outdated
@@ -13,5 +14,6 @@ void main() { | |||
}()); | |||
LicenseRegistry.addLicense(additionalLicenses); | |||
LiveZulipBinding.ensureInitialized(); | |||
tz.initializeTimeZones(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder how much this adds to startup time.
If it's more than like a couple of milliseconds, it may be better to defer it to be done lazily.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I've run it a few times using the Stopwatch
helper and it appears to take between 80-100 milliseconds (for comparison, LicenseRegistry.addLicense
is generally 0 and LiveZulipBinding.ensureInitialized
is ~2). I think we'll need to figure out a way to optimize this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dang. Yeah, I agree.
For cross-reference, chat thread here: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/timezone.20initialization.20time/near/1633575
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(And we ended up splitting this feature out as its own issue: #292.)
lib/api/model/initial_snapshot.dart
Outdated
@@ -78,6 +78,7 @@ class InitialSnapshot { | |||
required this.zulipVersion, | |||
this.zulipMergeBase, | |||
required this.alertWords, | |||
required this.realmDefaultExternalAccounts, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: put the fields in the same order here on the constructor as their declarations
lib/widgets/profile.dart
Outdated
case CustomProfileFieldType.unknown: | ||
return Padding( | ||
padding: const EdgeInsets.all(4), | ||
child: Text(entry.text)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the type is one we don't know about, this will cheerfully carry on and show the raw contents (which are likely some kind of JSON blob) to the user as if the other user had typed that in as plain text. That's potentially pretty odd-looking.
Looks like the zulip-mobile implementation handles this case by skipping that custom profile field entirely, which I think is pretty reasonable. The most thorough thing would I guess be to instead explicitly say that it's there and that this app doesn't support it.
lib/widgets/profile.dart
Outdated
children: [ | ||
Avatar(userId: user.userId, size: 32, borderRadius: 32 / 8), | ||
const SizedBox(width: 8), | ||
Text(user.fullName), // TODO(#196) render active status |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a test for a user with a long name — I expect it'll throw an error for overflow in this Row
.
lib/widgets/profile.dart
Outdated
child: Column( | ||
children: [ | ||
const SizedBox(height: 16), | ||
Table( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat! I don't think I've seen this widget before.
lib/widgets/profile.dart
Outdated
columnWidths: const { | ||
0: FixedColumnWidth(116), | ||
1: FlexColumnWidth()}, | ||
defaultVerticalAlignment: TableCellVerticalAlignment.top, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does TableCellVerticalAlignment.baseline
behave when one cell has multiple lines of text? If we can get basically top alignment, but with the baselines of the first lines instead of the top edges, that seems ideal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using TableCellVerticalAlignment.baseline
with TextBaseline.alphabetic
still maintains top alignment, so the ideal condition has been achieved!
lib/widgets/profile.dart
Outdated
Table( | ||
border: TableBorder.all(style: BorderStyle.none), | ||
columnWidths: const { | ||
0: FixedColumnWidth(116), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does 116 come from?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was supposed to be based on the label width from the mobile app which was 96px for the unpadded text. I've since moved the padding around and didn't readjust the formula here (because this column needs to contain padded text). I've replaced it with 96 + 8
to match the old width with the new padding values.
f4648d4
to
7cbb4fb
Compare
@gnprice this is ready for another go! Thank you for the comments, I'm a lot happier with the state of the profile page rendering. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi for the revision! This is definitely getting closer.
Comments below. In particular this time I went and played with the UI, which I hadn't so much in the previous rounds.
Along with the next revision, a helpful workflow step would be to include some screenshots — that's helpful especially for people following along who may not have the PR branch checked out locally. (Similarly it can also be helpful when we're looking back later, even for the author or main reviewer, to make it easy to remember just which PR did what and what the UI looked like at a given previous stage.) Probably most helpful is to edit the screenshots into the PR description, so it can't get buried in the length of the thread.
lib/model/narrow.dart
Outdated
@@ -161,6 +161,13 @@ class DmNarrow extends Narrow implements SendableNarrow { | |||
); | |||
} | |||
|
|||
factory DmNarrow.withUser(int userId, {required int selfUserId}) { | |||
return DmNarrow( | |||
allRecipientIds: {userId, selfUserId}.toList()..sort(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat. A bit tricky, though. Would definitely be good to have some tests for this — really just needs three cases, where the target user ID is equal to, less than, or greater than the self ID.
In particular I saw this code and was tempted to say it can simplify to [userId, selfUserId]..sort()
, and only then remembered why we extracted it in the first place 🙂
lib/api/model/initial_snapshot.dart
Outdated
required this.realmDefaultExternalAccounts, | ||
required this.userSettings, | ||
required this.maxFileUploadSizeMib, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: match ordering of fields' declarations
required this.realmDefaultExternalAccounts, | |
required this.userSettings, | |
required this.maxFileUploadSizeMib, | |
required this.userSettings, | |
required this.realmDefaultExternalAccounts, | |
required this.maxFileUploadSizeMib, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump
lib/widgets/profile.dart
Outdated
final User user; | ||
|
||
@override | ||
Widget build(BuildContext context) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Flutter upstream style seems to be that the build
method is always at the end of the class — a handy convention for quickly finding it, since that's often among the first things one wants to find. So then its various helper methods go above it.
lib/widgets/profile.dart
Outdated
])); | ||
} | ||
|
||
Widget? _renderCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
Widget? _renderCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) { | |
Widget? _buildCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) { |
to match the verb used for the other similar methods (and for functions returning a widget in general)
lib/model/store.dart
Outdated
customProfileFields.clear(); | ||
customProfileFields.addAll(_sortCustomProfileFields(event.fields)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: _sortCustomProfileFields
already allocates a List, so this makes an unnecessary copy; could either assign to customProfileFields
rather than clear/addAll (and make it non-final to accommodate that), or drop the toList
in _sortCustomProfileFields
and have the other call site do toList
for itself
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've changed customProfileFields
to be non-final instead. It seems this is the first non-final member, but it doesn't seem a pattern to maintain as I think there are some other top-level settings that will eventually need to be non-final (like realm_message_content_edit_limit_seconds
)
lib/widgets/profile.dart
Outdated
Table( | ||
border: TableBorder.all(style: BorderStyle.none), | ||
columnWidths: const { | ||
0: FixedColumnWidth(96 + 8), | ||
1: FlexColumnWidth()}, | ||
defaultVerticalAlignment: TableCellVerticalAlignment.baseline, | ||
textBaseline: TextBaseline.alphabetic, | ||
children: entries.map((entry) => TableRow( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this Table
widget seems neat and is good to be aware of, but I'm wondering now if it's actually doing much for us in this case. It looks like we should be able to get the same effect with just a Column whose children are Rows; in each row use a SizedBox(width: …, child: …)
to fix the width of the label and an Expanded
to let the value take up the rest of the width.
I come to that thought from looking at the FixedColumnWidth(96 + 8)
and noticing that the numbers still feel a bit mysterious; and in particular that the 8 is meant to correspond to the 8 down below in the padding, but they're separated by a good few lines of code that's talking about other things. The plain Row treatment would make it, in the first instance,
SizedBox(width: 96 + 8,
child: Padding(padding: const EdgeInsets.fromLTRB(0, 4, 8, 4),
child: Text(entry.label, style: const TextStyle(fontWeight: FontWeight.bold)))),
so that those numbers are right next to each other; and then at that point the idiomatic thing would be to break it up further:
SizedBox(width: 96,
child: Padding(padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(entry.label, style: const TextStyle(fontWeight: FontWeight.bold)))),
const SizedBox(width: 8),
at which point I think it's a good bit clearer what's going on.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, and perhaps even a greater benefit of the plain Column/Row approach: that should let us drop all the 4px vertical padding on the individual labels and values, in favor of interspersing const SizedBox(height: 8)
as children of the Column.
lib/widgets/profile.dart
Outdated
final choiceItem = choiceFieldData.choices[value]; | ||
if (choiceItem == null) return null; | ||
return _buildTextWidget(choiceItem.text); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Together with the if (choiceFieldData == null) return null;
line in my suggestion above, this can be compacted to:
final choiceItem = choiceFieldData.choices[value]; | |
if (choiceItem == null) return null; | |
return _buildTextWidget(choiceItem.text); | |
final choiceText = choiceFieldData?.choices[value]?.text; | |
return choiceText == null ? null : _buildTextWidget(choiceText); |
lib/widgets/profile.dart
Outdated
case CustomProfileFieldType.shortText: | ||
case CustomProfileFieldType.longText: | ||
case CustomProfileFieldType.pronouns: | ||
return _buildTextWidget(value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
case CustomProfileFieldType.shortText: | |
case CustomProfileFieldType.longText: | |
case CustomProfileFieldType.pronouns: | |
return _buildTextWidget(value); | |
case CustomProfileFieldType.shortText: | |
case CustomProfileFieldType.longText: | |
case CustomProfileFieldType.pronouns: | |
// The web client appears to treat `longText` identically to `shortText`; | |
// `pronouns` is explicitly meant to display the same as `shortText`. | |
return _buildTextWidget(value); |
(Copying from the zulip-mobile implementation.) This comment is basically another piece of the reverse-engineering results that substitute here for API docs. Even though it doesn't involve any arbitrary names (like choices
, text
, subtype
, etc., as seen for some of the other CustomProfileFieldType values), it's information about the semantics of these types which wouldn't be obvious a priori.
lib/widgets/profile.dart
Outdated
|
||
Widget _buildTextWidget(String text) { | ||
return Padding( | ||
padding: const EdgeInsets.symmetric(vertical: 4), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels vertically pretty cramped to me:
(The difference in URL text is due to the bug I mentioned in a comment above where the arguments to _buildLinkWidget
are swapped.)
I see that the source code in both implementations appears to call for the same 8px of spacing in between, though. Hmm — I suspect this TextStyle.height
setting is the issue:
style: const TextStyle(fontSize: 15, height: 1),
Aha, yep, if I remove both that and the height: 1.5
elsewhere, it gets more spacious. I think a typical TextStyle.height
is more like 1.2, not 1.0.
The best solution here may be to just make less use of DefaultTextStyle
— or at least to not use it for TextStyle.height
. In particular the upper DefaultTextStyle
is only applying to three Text widgets that I see, plus to _ProfileDataTable
which overrides most of it; and of those three, one is a button label, which seems like it shouldn't necessarily get the same styles as the information text anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll remove it from the profile data table, in the refactor away from the Table
widget I'll have the Row
builders apply whatever style to the labels. I think it's still useful to have it on the upper part, although it only has three Text
widgets currently, there will be more fields to be added later when the features backing them are introduced.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, that seems like a reasonable strategy. Though it looks like in the revision, you ended up doing away with the DefaultTextStyle
entirely, in favor of having style constants (on a container class) and referring to those directly at Text
widgets, and that approach looks good to me.
lib/widgets/profile.dart
Outdated
return InkWell( | ||
onTap: () => Navigator.push(context, | ||
ProfilePage.buildRoute(context: context, | ||
userId: userId)), | ||
child: Padding( | ||
padding: const EdgeInsets.symmetric(vertical: 4), | ||
child: Row( | ||
children: [ | ||
Avatar(userId: userId, size: 32, borderRadius: 32 / 8), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The InkWell needs some horizontal padding too — the highlighting looks odd at the left edge because it's flush with the avatar. (And if the user's name were just long enough to reach the right edge, I expect that'd look odd too.)
7cbb4fb
to
67b3854
Compare
@gnprice Ready for the next round! |
import 'page.dart'; | ||
import 'store.dart'; | ||
|
||
class _TextStyles { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've seen in other places usage of private _kFooBar
variables used for reusable style following their 'Begin global constant names with prefix "k"' rule, but the same rule recommends using a container class over that. I do personally prefer this style, so I elected for refactor in this way for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, this approach looks good to me.
d92a018
to
66182bd
Compare
test/model/narrow_test.dart
Outdated
test('ofUser: same user', () { | ||
final actual = DmNarrow.withUser(5, selfUserId: 5); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: have test agree with tested code on name of constructor
test('ofUser: same user', () { | |
final actual = DmNarrow.withUser(5, selfUserId: 5); | |
test('withUser: same user', () { | |
final actual = DmNarrow.withUser(5, selfUserId: 5); |
test/model/narrow_test.dart
Outdated
selfUserId: 5)); | ||
}); | ||
|
||
test('ofUser: user less than selfUserId', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
test('ofUser: user less than selfUserId', () { | |
test('ofUser: user ID less than selfUserId', () { |
(plus the other change)
lib/api/model/model.dart
Outdated
int? toJson() => apiValue; | ||
} | ||
|
||
/// The realm-level field data item for a "choice" custom profile field. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// The realm-level field data item for a "choice" custom profile field. | |
/// An item in the realm-level field data for a "choice" custom profile field. |
lib/api/model/model.dart
Outdated
/// This is the decoding of values of [CustomProfileField.fieldData] when | ||
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// A list of these is the decoding of values of [CustomProfileField.fieldData] when
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice].
or that then sounds a bit awkward, so perhaps better:
/// This is the decoding of values of [CustomProfileField.fieldData] when | |
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice]. | |
/// The value of [CustomProfileField.fieldData] decodes to a | |
/// `List<CustomProfileFieldChoiceDataItem>` when | |
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice]. |
lib/api/model/initial_snapshot.dart
Outdated
required this.realmDefaultExternalAccounts, | ||
required this.userSettings, | ||
required this.maxFileUploadSizeMib, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump
test/api/model/model_test.dart
Outdated
check(choices['0']?.text).equals('Option 0'); | ||
check(choices['1']?.text).equals('Option 1'); | ||
check(choices['2']?.text).equals('Option 2'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This checks these three elements are present but not that that's the whole map. Not a super high-risk thing to break, here, but it'd be nice to be complete.
One route is with deepEquals
. That seems to end up a bit annoying with isA
checks:
check(choices).deepEquals({
'0': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 0'),
'1': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 1'),
'2': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 2'),
});
though tolerable.
Another route would be deepEquals
plus adding an ==
override.
Maybe the cleanest is our jsonEquals
:
check(choices).jsonEquals({
'0': const CustomProfileFieldChoiceDataItem(text: 'Option 0'),
'1': const CustomProfileFieldChoiceDataItem(text: 'Option 1'),
'2': const CustomProfileFieldChoiceDataItem(text: 'Option 2'),
});
lib/widgets/profile.dart
Outdated
Icon(Icons.error), | ||
Text('Could not show user profile.'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump #287 (comment)
It looks like some padding was added between the app bar and the error icon + text, which is probably good too. But the icon and the text look too close to each other.
import 'page.dart'; | ||
import 'store.dart'; | ||
|
||
class _TextStyles { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, this approach looks good to me.
lib/widgets/profile.dart
Outdated
import 'store.dart'; | ||
|
||
class _TextStyles { | ||
static const profileText = TextStyle(fontSize: 20); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name is a bit unspecific — the whole page is the profile, so it doesn't help much in narrowing down which text this is for.
It seems like the answer is: currently name and role, and in the future perhaps email, user status, local time, etc. In other words the fields that are shown above the custom profile fields.
Perhaps headingText
? Or primaryFieldText
? Or could be profileHeadingText
or profilePrimaryFieldText
, though it's already private to the profile-page code so that doesn't seem essential.
lib/widgets/profile.dart
Outdated
|
||
Widget _buildTextWidget(String text) { | ||
return Padding( | ||
padding: const EdgeInsets.symmetric(vertical: 4), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, that seems like a reasonable strategy. Though it looks like in the revision, you ended up doing away with the DefaultTextStyle
entirely, in favor of having style constants (on a container class) and referring to those directly at Text
widgets, and that approach looks good to me.
Thanks for the revision! This is all generally looking good. Comments above — all pretty small — from looking back through my previous review and reading the related bits of code. Haven't yet looked at all the changes, but will do that next. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and I've now finished reading the entire code in the PR, including the tests. Comments below — mostly in the tests, mostly small.
lib/widgets/profile.dart
Outdated
onPressed: () => Navigator.push(context, | ||
MessageListPage.buildRoute(context: context, | ||
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
onPressed: () => Navigator.push(context, | |
MessageListPage.buildRoute(context: context, | |
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))), | |
onPressed: () => Navigator.push(context, | |
MessageListPage.buildRoute(context: context, | |
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))), |
lib/widgets/profile.dart
Outdated
appBar: AppBar(title: const Text('Error')), | ||
body: const SingleChildScrollView( | ||
child: Padding( | ||
padding: EdgeInsets.fromLTRB(16, 32, 16, 16), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: probably cleanest if the padding is symmetric, no? That is, the same 32px padding below as above.
(Cleanest in the visual result if it ever matters, though that's unlikely; and cleanest to think about in the code in any case.)
lib/widgets/profile.dart
Outdated
if (profileData == null) return const SizedBox.shrink(); | ||
|
||
List<Widget> items = [const SizedBox(height: 16)]; | ||
|
||
for (final realmField in store.customProfileFields) { | ||
final profileField = profileData![realmField.id]; | ||
if (profileField == null) continue; | ||
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField); | ||
if (widget == null) continue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will end up rendering as blank or empty in one way if the user has no custom profile fields set (i.e. profileData == null
), and in a different way if they do but none of them validate (they hit the widget == null
continue here, or they don't match any field ID in the realm-wide store.customProfileFields
).
Probably the SizedBox.shrink()
is the better layout — the extra padding will likely look odd if there's nothing else there. So we can have a conditional for that after the loop if it turns out we didn't add any items.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also the user could have profileData
be an empty Map rather than null, if they previously had a field and then we got an event to delete it. See PerAccountData.handleEvent
.
We don't promise otherwise at the field definition:
// null for bots, which don't have custom profile fields.
// If null for a non-bot, equivalent to `{}` (null just written for efficiency.)
@JsonKey(readValue: _readProfileData)
Map<int, ProfileFieldUserData>? profileData;
instead only saying anything about what it means if it is null.
Possibly we should, though. It'd only be another line or two of code in handleEvent
to ensure that, and the existence of both possible states is a bit of a pitfall.
(I think this build method's code wouldn't actually be any different if we made that change, though, since we have to go through the loop anyway to see if any of the fields validate.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've gone ahead and updated code in handleEvent
for it.
lib/widgets/profile.dart
Outdated
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField); | ||
if (widget == null) continue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Relatedly:
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField); | |
if (widget == null) continue; | |
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField); | |
if (widget == null) continue; // TODO(log) |
This is the sort of condition that shouldn't happen, and that therefore in zulip-mobile we'd log to Sentry as a warning so that we can notice if it's getting hit in reality. We haven't yet worked out the framework for that in zulip-flutter, but these TODO(log)
comments will be helpful for sweeping back through such places when we do.
test/example_data.dart
Outdated
List<ZulipStream>? streams, | ||
Map<String, RealmDefaultExternalAccount>? realmDefaultExternalAccounts, | ||
UserSettings? userSettings, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the order of parameters here should match the order where they're passed to the constructor below (and both should match the order declared on the constructor, since this function is little more than a variation on the constructor with a bunch of defaults for the parameters)
test/widgets/profile_test.dart
Outdated
await tester.tap(targetWidget, warnIfMissed: false); | ||
check(pushedRoutes).last.isA<WidgetRoute>().page | ||
.isA<MessageListPage>() | ||
.narrow.equals(expectedNarrow); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: probably clearest if this just inlines expectedNarrow
test/widgets/profile_test.dart
Outdated
}); | ||
|
||
testWidgets('page builds; ensure long values do not overflow', (WidgetTester tester) async { | ||
final longString = List.filled(400, 'X').join(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
final longString = List.filled(400, 'X').join(); | |
final longString = 'X' * 400; |
test/widgets/profile_test.dart
Outdated
await setupPage(tester, users:[user], pageUserId: user.userId, customProfileFields: [ | ||
mkCustomProfileField(0, CustomProfileFieldType.shortText), | ||
mkCustomProfileField(1, CustomProfileFieldType.user), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's cover all the field types here (except perhaps date
which can't be long with realistic data).
Particularly key is to cover links, since that's a separate widget; but really it's just an extra two or three lines to cover each type, so might as well be complete.
test/widgets/profile_test.dart
Outdated
final findAvatars = find.byType(Avatar); | ||
check(findAvatars.evaluate()).length.equals(2 + 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This "2 + 1" feels a bit brittle to me; in particular the "+ 1" is implicitly referring to the fact that the top of the screen also shows an Avatar.
One approach is to make that more explicit by getting the specific user IDs and checking the list:
final findAvatars = find.byType(Avatar); | |
check(findAvatars.evaluate()).length.equals(2 + 1); | |
final avatars = tester.widgetList<Avatar>(find.byType(Avatar)); | |
check(avatars.map((w) => w.userId).toList()) | |
.deepEquals([1, 2, 3]); |
If some later tweak makes the big image at the top not an Avatar
widget, the test would still break; but it'd be a lot more evident how it broke and what the intent was, and so how to update the test.
test/widgets/profile_test.dart
Outdated
eg.user(userId: 2, fullName: 'test user1'), | ||
eg.user(userId: 3, fullName: 'test user2'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: make the names match the user IDs — otherwise it may cause someone some confused debugging time in the future when fixing something that broke the test
66182bd
to
ea6984b
Compare
@gnprice comments handled again! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Just a handful of comments now, and all of them small. Then we'll be ready to merge!
lib/api/model/model.dart
Outdated
int? toJson() => apiValue; | ||
} | ||
|
||
/// An item in the realm-level field data item for a "choice" custom profile field. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// An item in the realm-level field data item for a "choice" custom profile field. | |
/// An item in the realm-level field data for a "choice" custom profile field. |
test/widgets/profile_test.dart
Outdated
mkCustomProfileField(2, CustomProfileFieldType.choice, | ||
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'), | ||
mkCustomProfileField(3, CustomProfileFieldType.link), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mkCustomProfileField(2, CustomProfileFieldType.choice, | |
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'), | |
mkCustomProfileField(3, CustomProfileFieldType.link), | |
mkCustomProfileField(2, CustomProfileFieldType.choice, | |
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'), | |
// no [CustomProfileFieldType.date] because those can't be made long | |
mkCustomProfileField(3, CustomProfileFieldType.link), |
That way if the reader notices that the list is almost but not quite complete, they don't have to wonder whether the omission is just an oversight that should be fixed.
test/widgets/profile_test.dart
Outdated
mkCustomProfileField(0, CustomProfileFieldType.shortText), | ||
mkCustomProfileField(1, CustomProfileFieldType.longText), | ||
mkCustomProfileField(2, CustomProfileFieldType.choice, | ||
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flutter analyze
complains, saying unnecessary_brace_in_string_interps
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Weird, android studio doesn't flag this in the project analysis but running flutter analyze
on the cli does flag it. I'll make sure to get better at checking these before pushing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm interesting. I haven't previously seen a divergence like that.
For me if I reintroduce the braces, Android Studio does flag it. Here's a screenshot (in an unnaturally small window to make the screenshot readable):
So it's there in the "Dart Analysis" pane at the bottom, and also in the list of issues at the top right of the editor pane (with the gray "!"-in-triangle icon — gray because it's an info-level issue). And the F2 key ("Next Highlighted Error") navigates to it.
Perhaps there's some Android Studio setting that affects that — like maybe that controls whether info-level issues are included? I don't remember changing such a setting, but it's possible I did long ago. If showing them isn't the default, it'd be good to put that setting in our .idea/
directory if we can, so that everyone working in our tree gets it automatically.
if (profileData.isEmpty) { | ||
// null is equivalent to `{}` for efficiency; see [User._readProfileData]. | ||
user.profileData = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: in commit message:
store: ensure empty map in profileData updates are converted to null
use sentence case for summary (i.e., s/ensure empty/Ensure empty/)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump
test/widgets/profile_test.dart
Outdated
await tester.pumpAndSettle(); | ||
} | ||
|
||
CustomProfileField mkCustomProfileField(int id, CustomProfileFieldType type, {int? order, bool? displayInProfileSummary, String? fieldData}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good thought!
ea6984b
to
c0f7552
Compare
@gnprice comments handled again, and I found an issue in the test that I've also fixed. |
Also removed extraneous TODO.
Applied BoxFit.cover to enlarge the image if the parent widget is larger than its natural size.
Change `CustomProfileField.type` into a proper enum, add support for server event `custom_profile_fields`.
required this.userSettings, | ||
required this.realmDefaultExternalAccounts, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tree isn't clean on rerunning dart run build_runner build
, because the generated file has these in a different order.
@@ -41,6 +39,8 @@ class InitialSnapshot { | |||
// TODO(server-5) remove pre-5.0 comment | |||
final UserSettings? userSettings; // TODO(server-5) | |||
|
|||
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: spelling in commit message:
api: Add realmDefaultExtrenalAccounts
if (profileData.isEmpty) { | ||
// null is equivalent to `{}` for efficiency; see [User._readProfileData]. | ||
user.profileData = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Couple of small issues remaining, above.
These are all tiny enough that I'll just fix them up and merge. For the generated-files issue, the handy command for updating everything at the right spot in the branch is git rebase -x
, as in git rebase -x 'dart run build_runner build'
.
c0f7552
to
c3df03e
Compare
Added profile screen with user information and custom profile fields, linked from sender name and avatar in message list.
Support in initial_snapshot and models for the related
RealmDefaultExternalAccounts
andCustomUserProfileFields
also added, as event handling forcustom_profile_fields
.User presence (#196) and user status (#197) are not yet displayed or tracked here.
Fixes: #195